Требуется изучить рынок заведений общественного питания Москвы и выявить текущие тренды. Необходимо выявить существующие закономерности в расположении, категорях общественного питания - ресторан, кафе, столловая и т.д, , ценообразовании, потенциальном трафике посетителей, кол-ве посадочных мест. Будем работать с данными о заведениях общественного питания Москвы, составленный на основе данных сервисов Яндекс Карты и Яндекс Бизнес на лето 2022 года. Информация, размещённая в сервисе Яндекс Бизнес, могла быть добавлена пользователями или найдена в общедоступных источниках.
Заказчик: Инвесторы из фонда «Shut Up and Take My Money»
Цель: Выбор подходящего места для открытия точки общественного питания
О качестве данных ничего не известно, поэтому перед проведением анализа потребуется обзор данных. Мы проверим данные на ошибки и оценим их влияние на исследование. Затем, на этапе предобработки мы поищем возможность исправить самые критичные ошибки данных. Затем проведем исследовательский анализ и закончим все выводами, полученными в ходе исследования.
Таким образом исследование пройдет в три этапа:
%pip install swifter
%pip install folium
%pip install plotly==5.5
Collecting swifter
Downloading swifter-1.3.4.tar.gz (830 kB)
|████████████████████████████████| 830 kB 1.4 MB/s eta 0:00:01
Requirement already satisfied: pandas>=1.0.0 in /opt/conda/lib/python3.9/site-packages (from swifter) (1.2.4)
Collecting psutil>=5.6.6
Downloading psutil-5.9.4-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl (280 kB)
|████████████████████████████████| 280 kB 59.6 MB/s eta 0:00:01
Collecting dask[dataframe]>=2.10.0
Downloading dask-2022.12.0-py3-none-any.whl (1.1 MB)
|████████████████████████████████| 1.1 MB 81.5 MB/s eta 0:00:01
Requirement already satisfied: tqdm>=4.33.0 in /opt/conda/lib/python3.9/site-packages (from swifter) (4.61.2)
Requirement already satisfied: ipywidgets>=7.0.0 in /opt/conda/lib/python3.9/site-packages (from swifter) (7.6.3)
Collecting cloudpickle>=0.2.2
Downloading cloudpickle-2.2.0-py3-none-any.whl (25 kB)
Requirement already satisfied: parso>0.4.0 in /opt/conda/lib/python3.9/site-packages (from swifter) (0.8.2)
Requirement already satisfied: bleach>=3.1.1 in /opt/conda/lib/python3.9/site-packages (from swifter) (3.3.0)
Requirement already satisfied: webencodings in /opt/conda/lib/python3.9/site-packages (from bleach>=3.1.1->swifter) (0.5.1)
Requirement already satisfied: packaging in /opt/conda/lib/python3.9/site-packages (from bleach>=3.1.1->swifter) (21.3)
Requirement already satisfied: six>=1.9.0 in /opt/conda/lib/python3.9/site-packages (from bleach>=3.1.1->swifter) (1.16.0)
Requirement already satisfied: pyyaml>=5.3.1 in /opt/conda/lib/python3.9/site-packages (from dask[dataframe]>=2.10.0->swifter) (6.0)
Collecting fsspec>=0.6.0
Downloading fsspec-2022.11.0-py3-none-any.whl (139 kB)
|████████████████████████████████| 139 kB 59.8 MB/s eta 0:00:01
Collecting partd>=0.3.10
Downloading partd-1.3.0-py3-none-any.whl (18 kB)
Requirement already satisfied: click>=7.0 in /opt/conda/lib/python3.9/site-packages (from dask[dataframe]>=2.10.0->swifter) (8.1.3)
Collecting toolz>=0.8.2
Downloading toolz-0.12.0-py3-none-any.whl (55 kB)
|████████████████████████████████| 55 kB 1.3 MB/s eta 0:00:01
Requirement already satisfied: numpy>=1.18 in /opt/conda/lib/python3.9/site-packages (from dask[dataframe]>=2.10.0->swifter) (1.21.1)
Requirement already satisfied: nbformat>=4.2.0 in /opt/conda/lib/python3.9/site-packages (from ipywidgets>=7.0.0->swifter) (5.1.3)
Requirement already satisfied: jupyterlab-widgets>=1.0.0 in /opt/conda/lib/python3.9/site-packages (from ipywidgets>=7.0.0->swifter) (3.0.2)
Requirement already satisfied: ipykernel>=4.5.1 in /opt/conda/lib/python3.9/site-packages (from ipywidgets>=7.0.0->swifter) (6.0.1)
Requirement already satisfied: traitlets>=4.3.1 in /opt/conda/lib/python3.9/site-packages (from ipywidgets>=7.0.0->swifter) (5.0.5)
Requirement already satisfied: ipython>=4.0.0 in /opt/conda/lib/python3.9/site-packages (from ipywidgets>=7.0.0->swifter) (7.25.0)
Requirement already satisfied: widgetsnbextension~=3.5.0 in /opt/conda/lib/python3.9/site-packages (from ipywidgets>=7.0.0->swifter) (3.5.2)
Requirement already satisfied: jupyter-client in /opt/conda/lib/python3.9/site-packages (from ipykernel>=4.5.1->ipywidgets>=7.0.0->swifter) (6.1.12)
Requirement already satisfied: debugpy>=1.0.0 in /opt/conda/lib/python3.9/site-packages (from ipykernel>=4.5.1->ipywidgets>=7.0.0->swifter) (1.3.0)
Requirement already satisfied: tornado>=4.2 in /opt/conda/lib/python3.9/site-packages (from ipykernel>=4.5.1->ipywidgets>=7.0.0->swifter) (6.1)
Requirement already satisfied: prompt-toolkit!=3.0.0,!=3.0.1,<3.1.0,>=2.0.0 in /opt/conda/lib/python3.9/site-packages (from ipython>=4.0.0->ipywidgets>=7.0.0->swifter) (3.0.19)
Requirement already satisfied: backcall in /opt/conda/lib/python3.9/site-packages (from ipython>=4.0.0->ipywidgets>=7.0.0->swifter) (0.2.0)
Requirement already satisfied: setuptools>=18.5 in /opt/conda/lib/python3.9/site-packages (from ipython>=4.0.0->ipywidgets>=7.0.0->swifter) (49.6.0.post20210108)
Requirement already satisfied: pexpect>4.3 in /opt/conda/lib/python3.9/site-packages (from ipython>=4.0.0->ipywidgets>=7.0.0->swifter) (4.8.0)
Requirement already satisfied: pickleshare in /opt/conda/lib/python3.9/site-packages (from ipython>=4.0.0->ipywidgets>=7.0.0->swifter) (0.7.5)
Requirement already satisfied: matplotlib-inline in /opt/conda/lib/python3.9/site-packages (from ipython>=4.0.0->ipywidgets>=7.0.0->swifter) (0.1.2)
Requirement already satisfied: jedi>=0.16 in /opt/conda/lib/python3.9/site-packages (from ipython>=4.0.0->ipywidgets>=7.0.0->swifter) (0.18.0)
Requirement already satisfied: decorator in /opt/conda/lib/python3.9/site-packages (from ipython>=4.0.0->ipywidgets>=7.0.0->swifter) (5.0.9)
Requirement already satisfied: pygments in /opt/conda/lib/python3.9/site-packages (from ipython>=4.0.0->ipywidgets>=7.0.0->swifter) (2.9.0)
Requirement already satisfied: jsonschema!=2.5.0,>=2.4 in /opt/conda/lib/python3.9/site-packages (from nbformat>=4.2.0->ipywidgets>=7.0.0->swifter) (3.2.0)
Requirement already satisfied: jupyter-core in /opt/conda/lib/python3.9/site-packages (from nbformat>=4.2.0->ipywidgets>=7.0.0->swifter) (4.7.1)
Requirement already satisfied: ipython-genutils in /opt/conda/lib/python3.9/site-packages (from nbformat>=4.2.0->ipywidgets>=7.0.0->swifter) (0.2.0)
Requirement already satisfied: pyrsistent>=0.14.0 in /opt/conda/lib/python3.9/site-packages (from jsonschema!=2.5.0,>=2.4->nbformat>=4.2.0->ipywidgets>=7.0.0->swifter) (0.17.3)
Requirement already satisfied: attrs>=17.4.0 in /opt/conda/lib/python3.9/site-packages (from jsonschema!=2.5.0,>=2.4->nbformat>=4.2.0->ipywidgets>=7.0.0->swifter) (21.2.0)
Requirement already satisfied: pyparsing!=3.0.5,>=2.0.2 in /opt/conda/lib/python3.9/site-packages (from packaging->bleach>=3.1.1->swifter) (2.4.7)
Requirement already satisfied: python-dateutil>=2.7.3 in /opt/conda/lib/python3.9/site-packages (from pandas>=1.0.0->swifter) (2.8.1)
Requirement already satisfied: pytz>=2017.3 in /opt/conda/lib/python3.9/site-packages (from pandas>=1.0.0->swifter) (2021.1)
Collecting locket
Downloading locket-1.0.0-py2.py3-none-any.whl (4.4 kB)
Requirement already satisfied: ptyprocess>=0.5 in /opt/conda/lib/python3.9/site-packages (from pexpect>4.3->ipython>=4.0.0->ipywidgets>=7.0.0->swifter) (0.7.0)
Requirement already satisfied: wcwidth in /opt/conda/lib/python3.9/site-packages (from prompt-toolkit!=3.0.0,!=3.0.1,<3.1.0,>=2.0.0->ipython>=4.0.0->ipywidgets>=7.0.0->swifter) (0.2.5)
Requirement already satisfied: notebook>=4.4.1 in /opt/conda/lib/python3.9/site-packages (from widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (6.4.0)
Requirement already satisfied: terminado>=0.8.3 in /opt/conda/lib/python3.9/site-packages (from notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (0.10.1)
Requirement already satisfied: pyzmq>=17 in /opt/conda/lib/python3.9/site-packages (from notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (22.1.0)
Requirement already satisfied: prometheus-client in /opt/conda/lib/python3.9/site-packages (from notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (0.11.0)
Requirement already satisfied: argon2-cffi in /opt/conda/lib/python3.9/site-packages (from notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (20.1.0)
Requirement already satisfied: jinja2 in /opt/conda/lib/python3.9/site-packages (from notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (3.0.1)
Requirement already satisfied: nbconvert in /opt/conda/lib/python3.9/site-packages (from notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (6.1.0)
Requirement already satisfied: Send2Trash>=1.5.0 in /opt/conda/lib/python3.9/site-packages (from notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (1.7.1)
Requirement already satisfied: cffi>=1.0.0 in /opt/conda/lib/python3.9/site-packages (from argon2-cffi->notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (1.14.5)
Requirement already satisfied: pycparser in /opt/conda/lib/python3.9/site-packages (from cffi>=1.0.0->argon2-cffi->notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (2.20)
Requirement already satisfied: MarkupSafe>=2.0 in /opt/conda/lib/python3.9/site-packages (from jinja2->notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (2.1.1)
Requirement already satisfied: entrypoints>=0.2.2 in /opt/conda/lib/python3.9/site-packages (from nbconvert->notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (0.3)
Requirement already satisfied: testpath in /opt/conda/lib/python3.9/site-packages (from nbconvert->notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (0.5.0)
Requirement already satisfied: pandocfilters>=1.4.1 in /opt/conda/lib/python3.9/site-packages (from nbconvert->notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (1.4.2)
Requirement already satisfied: nbclient<0.6.0,>=0.5.0 in /opt/conda/lib/python3.9/site-packages (from nbconvert->notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (0.5.3)
Requirement already satisfied: jupyterlab-pygments in /opt/conda/lib/python3.9/site-packages (from nbconvert->notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (0.1.2)
Requirement already satisfied: mistune<2,>=0.8.1 in /opt/conda/lib/python3.9/site-packages (from nbconvert->notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (0.8.4)
Requirement already satisfied: defusedxml in /opt/conda/lib/python3.9/site-packages (from nbconvert->notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (0.7.1)
Requirement already satisfied: async-generator in /opt/conda/lib/python3.9/site-packages (from nbclient<0.6.0,>=0.5.0->nbconvert->notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (1.10)
Requirement already satisfied: nest-asyncio in /opt/conda/lib/python3.9/site-packages (from nbclient<0.6.0,>=0.5.0->nbconvert->notebook>=4.4.1->widgetsnbextension~=3.5.0->ipywidgets>=7.0.0->swifter) (1.5.1)
Building wheels for collected packages: swifter
Building wheel for swifter (setup.py) ... done
Created wheel for swifter: filename=swifter-1.3.4-py3-none-any.whl size=16307 sha256=bf07a820ca5eddf3daece9e0ff128631d904c1f8bcf41f249108aa3397c5ddfc
Stored in directory: /home/jovyan/.cache/pip/wheels/2b/5e/f2/3931524f702ffd03309e96d35ee2fbf9c61c27377511ee8d4c
Successfully built swifter
Installing collected packages: toolz, locket, partd, fsspec, cloudpickle, dask, psutil, swifter
Successfully installed cloudpickle-2.2.0 dask-2022.12.0 fsspec-2022.11.0 locket-1.0.0 partd-1.3.0 psutil-5.9.4 swifter-1.3.4 toolz-0.12.0
Note: you may need to restart the kernel to use updated packages.
Requirement already satisfied: folium in /opt/conda/lib/python3.9/site-packages (0.12.1.post1)
Requirement already satisfied: branca>=0.3.0 in /opt/conda/lib/python3.9/site-packages (from folium) (0.5.0)
Requirement already satisfied: jinja2>=2.9 in /opt/conda/lib/python3.9/site-packages (from folium) (3.0.1)
Requirement already satisfied: numpy in /opt/conda/lib/python3.9/site-packages (from folium) (1.21.1)
Requirement already satisfied: requests in /opt/conda/lib/python3.9/site-packages (from folium) (2.25.1)
Requirement already satisfied: MarkupSafe>=2.0 in /opt/conda/lib/python3.9/site-packages (from jinja2>=2.9->folium) (2.1.1)
Requirement already satisfied: chardet<5,>=3.0.2 in /opt/conda/lib/python3.9/site-packages (from requests->folium) (4.0.0)
Requirement already satisfied: certifi>=2017.4.17 in /opt/conda/lib/python3.9/site-packages (from requests->folium) (2022.6.15)
Requirement already satisfied: urllib3<1.27,>=1.21.1 in /opt/conda/lib/python3.9/site-packages (from requests->folium) (1.26.6)
Requirement already satisfied: idna<3,>=2.5 in /opt/conda/lib/python3.9/site-packages (from requests->folium) (2.10)
Note: you may need to restart the kernel to use updated packages.
Collecting plotly==5.5
Downloading plotly-5.5.0-py2.py3-none-any.whl (26.5 MB)
|████████████████████████████████| 26.5 MB 1.3 MB/s eta 0:00:01
Requirement already satisfied: tenacity>=6.2.0 in /opt/conda/lib/python3.9/site-packages (from plotly==5.5) (8.0.1)
Requirement already satisfied: six in /opt/conda/lib/python3.9/site-packages (from plotly==5.5) (1.16.0)
Installing collected packages: plotly
Attempting uninstall: plotly
Found existing installation: plotly 5.4.0
Uninstalling plotly-5.4.0:
Successfully uninstalled plotly-5.4.0
Successfully installed plotly-5.5.0
Note: you may need to restart the kernel to use updated packages.
from folium import Map, Choropleth, Marker
from folium.plugins import MarkerCluster
from folium.features import CustomIcon
import json
import numpy as np
import matplotlib.pyplot as plt
from math import cos, asin, sqrt
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import swifter
import string
import warnings
Читаем датасет в pandas
pd.set_option('display.max_rows', None)
warnings.filterwarnings('ignore')
try:
data = pd.read_csv('moscow_places.csv')
except:
data = pd.read_csv('/datasets/moscow_places.csv')
Посмотрим что из себя представляют данные:
data.head(5)
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | WoWфли | кафе | Москва, улица Дыбенко, 7/1 | Северный административный округ | ежедневно, 10:00–22:00 | 55.878494 | 37.478860 | 5.0 | NaN | NaN | NaN | NaN | 0 | NaN |
| 1 | Четыре комнаты | ресторан | Москва, улица Дыбенко, 36, корп. 1 | Северный административный округ | ежедневно, 10:00–22:00 | 55.875801 | 37.484479 | 4.5 | выше среднего | Средний счёт:1500–1600 ₽ | 1550.0 | NaN | 0 | 4.0 |
| 2 | Хазри | кафе | Москва, Клязьминская улица, 15 | Северный административный округ | пн-чт 11:00–02:00; пт,сб 11:00–05:00; вс 11:00... | 55.889146 | 37.525901 | 4.6 | средние | Средний счёт:от 1000 ₽ | 1000.0 | NaN | 0 | 45.0 |
| 3 | Dormouse Coffee Shop | кофейня | Москва, улица Маршала Федоренко, 12 | Северный административный округ | ежедневно, 09:00–22:00 | 55.881608 | 37.488860 | 5.0 | NaN | Цена чашки капучино:155–185 ₽ | NaN | 170.0 | 0 | NaN |
| 4 | Иль Марко | пиццерия | Москва, Правобережная улица, 1Б | Северный административный округ | ежедневно, 10:00–22:00 | 55.881166 | 37.449357 | 5.0 | средние | Средний счёт:400–600 ₽ | 500.0 | NaN | 1 | 148.0 |
Посмотрим на структуру данных:
data.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 8406 entries, 0 to 8405 Data columns (total 14 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 8406 non-null object 1 category 8406 non-null object 2 address 8406 non-null object 3 district 8406 non-null object 4 hours 7870 non-null object 5 lat 8406 non-null float64 6 lng 8406 non-null float64 7 rating 8406 non-null float64 8 price 3315 non-null object 9 avg_bill 3816 non-null object 10 middle_avg_bill 3149 non-null float64 11 middle_coffee_cup 535 non-null float64 12 chain 8406 non-null int64 13 seats 4795 non-null float64 dtypes: float64(6), int64(1), object(7) memory usage: 919.5+ KB
Мы видим, что часть данных в дата фрейме пропущена: hours, price, avg_bill, middle_avg_bill, middle_coffee_cup, seats. Для удобства и экономии памяти у некторых столбцов необходимо поменять формат данных: middle_avg_bill, middle_coffee_cup, seats.
data.hist(figsize=(12,12));
Никаких аномалий в данных не наблюдается, lat и lng - имеют гауссовское распределение, middle_avg_bill, middle_coffee_cup , seats - пуассоновское. Посмотрим на стат.характеристики данных:
data.describe(include='all')
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| count | 8406 | 8406 | 8406 | 8406 | 7870 | 8406.000000 | 8406.000000 | 8406.000000 | 3315 | 3816 | 3149.000000 | 535.000000 | 8406.000000 | 4795.000000 |
| unique | 5614 | 8 | 5753 | 9 | 1307 | NaN | NaN | NaN | 4 | 897 | NaN | NaN | NaN | NaN |
| top | Кафе | кафе | Москва, проспект Вернадского, 86В | Центральный административный округ | ежедневно, 10:00–22:00 | NaN | NaN | NaN | средние | Средний счёт:1000–1500 ₽ | NaN | NaN | NaN | NaN |
| freq | 189 | 2378 | 28 | 2242 | 759 | NaN | NaN | NaN | 2117 | 241 | NaN | NaN | NaN | NaN |
| mean | NaN | NaN | NaN | NaN | NaN | 55.750109 | 37.608570 | 4.229895 | NaN | NaN | 958.053668 | 174.721495 | 0.381275 | 108.421689 |
| std | NaN | NaN | NaN | NaN | NaN | 0.069658 | 0.098597 | 0.470348 | NaN | NaN | 1009.732845 | 88.951103 | 0.485729 | 122.833396 |
| min | NaN | NaN | NaN | NaN | NaN | 55.573942 | 37.355651 | 1.000000 | NaN | NaN | 0.000000 | 60.000000 | 0.000000 | 0.000000 |
| 25% | NaN | NaN | NaN | NaN | NaN | 55.705155 | 37.538583 | 4.100000 | NaN | NaN | 375.000000 | 124.500000 | 0.000000 | 40.000000 |
| 50% | NaN | NaN | NaN | NaN | NaN | 55.753425 | 37.605246 | 4.300000 | NaN | NaN | 750.000000 | 169.000000 | 0.000000 | 75.000000 |
| 75% | NaN | NaN | NaN | NaN | NaN | 55.795041 | 37.664792 | 4.400000 | NaN | NaN | 1250.000000 | 225.000000 | 1.000000 | 140.000000 |
| max | NaN | NaN | NaN | NaN | NaN | 55.928943 | 37.874466 | 5.000000 | NaN | NaN | 35000.000000 | 1568.000000 | 1.000000 | 1288.000000 |
Оценку данных сильно затрудняют прорущенные значения - NaN. Этим мы займемся после проверки дата фрейма на дубликаты
data.duplicated().sum()
0
Отлично! дубликаты в дата фрейме отсутствуют. Переходим к пропущенным данным
копейками никто не пользуется, поэтому конвертируем 'middle_avg_bill', 'middle_coffee_cup' в int
в столбце существуют только значения 0 и 1 , поэтому для сокращения потребления памяти конвертируем в int32
data['chain'].unique()
array([0, 1])
data['chain'] = data['chain'].astype(int)
data.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 8406 entries, 0 to 8405 Data columns (total 14 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 8406 non-null object 1 category 8406 non-null object 2 address 8406 non-null object 3 district 8406 non-null object 4 hours 7870 non-null object 5 lat 8406 non-null float64 6 lng 8406 non-null float64 7 rating 8406 non-null float64 8 price 3315 non-null object 9 avg_bill 3816 non-null object 10 middle_avg_bill 3149 non-null float64 11 middle_coffee_cup 535 non-null float64 12 chain 8406 non-null int64 13 seats 4795 non-null float64 dtypes: float64(6), int64(1), object(7) memory usage: 919.5+ KB
Ранее мы уже проверили датасет на наличие дубликатов, но это были явные дубликаты, т.е. строки полностью похожие друг на друга. А как быть с неявными дубликатами - опечатки, разное написание одного и того же названия, замена одного символа в названии другим и прочее? Попробуем их найти в столбце 'name'. Нужен алгоритм, т.к руками проверить 8400 названий занятие неральное:
Напишем функцию для поиска сопадений названий, через преобразование названий в набор символов:
def replace_all(val):
ps = string.punctuation + string.whitespace + "’" + "'"
for r in ps:
val = val.replace(r, '')
val = val.replace('and','')
return val
data['name_found1'] = data['name'].swifter.apply(replace_all).str.lower()
Pandas Apply: 0%| | 0/8406 [00:00<?, ?it/s]
Найдем дубликаты по новому столбцу 'name_found1' и 'address':
data[data.duplicated(['name_found1', 'address'],keep='last')]
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | name_found1 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 599 | В парке вкуснее! | кафе | Москва, Северный административный округ, район... | Северный административный округ | ежедневно, 10:00–21:00 | 55.854571 | 37.487254 | 2.2 | NaN | NaN | NaN | NaN | 0 | NaN | впаркевкуснее |
| 1430 | More poke | ресторан | Москва, Волоколамское шоссе, 11, стр. 2 | Северный административный округ | ежедневно, 09:00–21:00 | 55.806307 | 37.497566 | 4.2 | NaN | NaN | NaN | NaN | 0 | 188.0 | morepoke |
| 2211 | Раковарня Клешни и Хвосты | ресторан | Москва, проспект Мира, 118 | Северо-Восточный административный округ | ежедневно, 12:00–00:00 | 55.810553 | 37.638161 | 4.4 | NaN | NaN | NaN | NaN | 0 | 150.0 | раковарняклешниихвосты |
| 3091 | Хлеб да Выпечка | булочная | Москва, Ярцевская улица, 19 | Западный административный округ | ежедневно, 09:00–22:00 | 55.738886 | 37.411648 | 4.1 | NaN | NaN | NaN | NaN | 1 | 276.0 | хлебдавыпечка |
| 4613 | Cafe 13 | кафе | Москва, Мясницкая улица, 13, стр. 11 | Центральный административный округ | пн-чт 10:00–22:00; пт 10:00–19:00 | 55.762779 | 37.633079 | 4.3 | NaN | NaN | NaN | NaN | 0 | 200.0 | cafe13 |
| 5764 | VIP Wok & sushi | быстрое питание | Москва, Можайское шоссе, 45Б | Западный административный округ | ежедневно, 11:00–23:00 | 55.716484 | 37.407236 | 4.2 | NaN | NaN | NaN | NaN | 0 | 16.0 | vipwoksushi |
Удалим дубликаты по номерам строчек:
data.drop([4613, 1430, 5764, 599, 2211, 3091], inplace=True)
data.drop(columns=['name_found1'], inplace=True)
Таким, образом мы избавились от дубликатов данных в датасете. Идем дальше...
Проверим, как обстоят дела со столбцом:
data['category'].unique()
array(['кафе', 'ресторан', 'кофейня', 'пиццерия', 'бар,паб',
'быстрое питание', 'булочная', 'столовая'], dtype=object)
Дубликатов в столбце 'category' нет
Проверим, как обстоят дела со столбцом:
data['district'].unique()
array(['Северный административный округ',
'Северо-Восточный административный округ',
'Северо-Западный административный округ',
'Западный административный округ',
'Центральный административный округ',
'Восточный административный округ',
'Юго-Восточный административный округ',
'Южный административный округ',
'Юго-Западный административный округ'], dtype=object)
Дубликатов в столбце 'district' нет
Проверим длину списка уникальных значений столбца 'hours':
len(data['hours'].unique())
1307
data['price'].unique()
array([nan, 'выше среднего', 'средние', 'высокие', 'низкие'], dtype=object)
Дубликатов нет, всего 4 категории
Сократим вывод датасетов до 30 строк - для более комфортой работы
pd.set_option('display.max_rows', 30)
Будем создавать его из столбца 'address'
Напишим функцию получения улицы из полного адреса:
def get_street(val):
streets = ('улица','проезд', 'проспект', 'шоссе', 'площадь','бульвар',
'МКАД', 'парк', 'заказник', 'переулок', 'аллея', 'набережная', 'тупик',
'квартал', 'микрорайон', 'сквер', 'жилой комплек', 'просек')
districts = ['округ', 'поселок', 'район', 'город', 'деревня' ]
val_lst = val.split(',')
for ilocation in val_lst[1:]:
for loc in streets:
if loc in ilocation:
if loc != 'МКАД':
return ilocation
else:
return loc + ', ' + val_lst[2]
else:
continue
return ''
Применим ее к датасету
data['streets'] = data['address'].apply(get_street)
pd.set_option('display.max_colwidth', 50) # default 50 , None -no limit
pd.set_option('display.max_rows', 30)
data['is_24/7'] = data['hours'].apply(lambda x: True if x == 'ежедневно, круглосуточно' else False)
По итогам предобработки данных мы:
pt_cats = pd.pivot_table(data, index='category', values='name', aggfunc='count').sort_values(by=['name'], ascending=False).reset_index().rename(columns={'name':'кол-во'})
fig = px.bar(pt_cats, x='кол-во', y='category',title='Кол-во заведений по категориям')
fig.update_traces(textposition='inside', text=pt_cats['кол-во'])
fig.update_xaxes(title_text='Кол-во') # ось x
fig.update_yaxes(title_text='Категория', secondary_y=False) # ось y - primary
fig.show()
Как видно на графике больше всего в Москве заведенией в категории "кафе", далее следует категория "ресторан", на 3 месте находится категория "кофейня"
pt = pd.pivot_table(data, index='category', values='seats', aggfunc=['sum','median']).reset_index()
pt.columns = ['category', 'total_seats', 'avg_seats']
pt['avg_seats'] = round(pt['avg_seats'])
pt = pt.sort_values(by=['total_seats'], ascending=False)
fig = make_subplots(specs=[[{"secondary_y": True}]]) # создаем плотно на 2 оси y
# Создаем на полотне графики
fig.add_trace(go.Bar(x=pt['category'], y = pt['total_seats'], text=pt['total_seats'], name='Общее кол-во мест'), secondary_y=False)
fig.add_trace(go.Scatter(
x=pt['category'],
y = pt['avg_seats'],
text=pt['avg_seats'], # значения меток
textposition='top right', # расположение меток в верху в центре
mode='lines+markers+text', # режим обображения линий, маркеров и меток
name='Медианное кол-во мест'), # название кривой
secondary_y=True # указание на использование y-secondary
)
# Заголовок
fig.update_layout(title_text='Кол-во посадочных мест и среднее кол-во место в заведениях по категориям')
fig.update_xaxes(title_text='Категории') # ось x
fig.update_yaxes(title_text='Общее кол-во мест', secondary_y=False) # ось y - primary
fig.update_yaxes(title_text='Медианное кол-во мест', secondary_y=True) # ось y - secondary
fig.show()
Мы видим, что в случае общего кол-ва мест категории распределяются также как и в случае кол-ва заведений по категориям, т.е больше всего посадочных мест в категории 'ресторан', что вообщем-то не удивительно и меньше всего в 'булочная', что тоже не удивляет. А вот в случае со медианным кол-вом мест ситуация другая. Больше всего мест также в 'ресторан', что опять не удивительно, но такое же парктичеки такое же кол-во мест у категории 'бар, паб' и 'кофейня'.Удивляет медианное кол-во мест у категорий 'кафе' и 'пиццерия' - 60 и 55 мест соответственно. Это практически одни из самых маленьких значений. Посмотрим на распределение посадочных мест в категориях:
fig = px.box(data, x='category', y='seats', labels={'category':'категория', 'seats':'кол-во мест'})
fig.update_yaxes(range = [0,160])
fig.show()
На графике мы видим, что в категории 'кафе' максимальное кол-во заведений сконцентрировано в районе 60 посадочных мест, что и приводит к такому виду графика, котрый мы наблюдаем выше. тоже и с пиццерией большая часть заведений сконцентрирована в районе 55 мест.Ничего необычно в распределении нет.
Посмотрим на разделение заведений общественного питания Москвы на сетевые и несетевые:
s = data['chain'].value_counts()
fig = px.pie(s, values=s.values,
names={
'is_not_chain': 'не сетевые',
'is_chain': 'сетевые'
},
title='Кол-во заведений по категориям')
fig.update_traces(textposition='inside', textinfo='percent+label', hole=.3)
fig.update_layout(
autosize=False,
height=400,
width=1000,
annotations=[dict(text='Москва', x=0.5, y=0.5, font_size=20, showarrow=False)],
margin = dict(t=30, l=0, r=0, b=0)
)
fig.show()
Мы видим, что кол-во несетевых заведений в Москве в 1,6 раза больше чем сетевых. Посмотрим на разделение по категориям:
data['chain_ask'] = data['chain'].apply(lambda x: 'сетевые' if x == 1 else 'несетевые')
pv_cat_chain = pd.pivot_table(data, index=['category','chain_ask'], values='name', aggfunc='count').reset_index().rename(columns={'name':'qty'}).sort_values(by='qty', ascending=False)
pv_cat_chain
| category | chain_ask | qty | |
|---|---|---|---|
| 6 | кафе | несетевые | 1597 |
| 12 | ресторан | несетевые | 1311 |
| 7 | кафе | сетевые | 779 |
| 13 | ресторан | сетевые | 730 |
| 9 | кофейня | сетевые | 720 |
| 8 | кофейня | несетевые | 693 |
| 0 | бар,паб | несетевые | 596 |
| 4 | быстрое питание | несетевые | 370 |
| 11 | пиццерия | сетевые | 330 |
| 10 | пиццерия | несетевые | 303 |
| 5 | быстрое питание | сетевые | 232 |
| 14 | столовая | несетевые | 227 |
| 1 | бар,паб | сетевые | 169 |
| 3 | булочная | сетевые | 156 |
| 2 | булочная | несетевые | 99 |
| 15 | столовая | сетевые | 88 |
/
fig = px.bar(pv_cat_chain, x='category', y='qty', color='chain_ask',
labels={'qty':'процент', 'category': 'категория', 'chain_ask':''},
height=500,
text_auto=True,
title='Распределение сетевых и несетевых заведений по категориям')
fig.update_layout(
autosize=False,
height=600,
width=900,
barnorm='percent',
uniformtext=dict(minsize=11, mode='hide'), # все надписи одним разером шрифта - 10 , если не входит - скрыть
margin = dict(t=30, l=0, r=0, b=0)
)
fig.show()
По графику видно, что больше всего в процентном отношении сетевых заведений находится среди категорий 'кофейня', 'пиццерия' и 'булочная'. Доли сетевых заведений в этих категориях составляет более 50%. Во всех остальных категориях кол-во несетевых заведений преобладает над кол-вом сетевыми.
Названия районов Москвы очень длинные, поэтому для удобства сократим их до абривиатуры
def cut_district_name(val):
if val == 'Северный административный округ':
return 'САО'
if val == 'Северо-Восточный административный округ':
return 'СВАО'
if val == 'Северо-Западный административный округ':
return 'СЗАО'
if val == 'Западный административный округ':
return 'ЗАО'
if val == 'Центральный административный округ':
return 'ЦАО'
if val == 'Восточный административный округ':
return 'ВАО'
if val == 'Юго-Восточный административный округ':
return 'ЮВАО'
if val == 'Южный административный округ':
return 'ЮАО'
if val == 'Юго-Западный административный округ':
return 'ЮЗАО'
data['district1'] = data['district'].apply(cut_district_name)
Найдем топ 15 сейтей Москвы:
top_15 = data.loc[data['chain'] == 1,['name', 'category', 'chain']].groupby(['name'])['chain'].agg('count').reset_index().sort_values(by='chain', ascending=False).head(15)
top_15.columns = ['name','qty']
Добавим категорию к названию сети:
def find_category(val):
return data.apply(lambda row: row['category'] if row['name'] == val else '', axis=1).unique()[1]
top_15['category'] = top_15['name'].apply(find_category)
Проиллюстрируем данные выше при помощи графика:
fig = px.bar(top_15, x='name', y='qty',
labels={'qty':'кол-во', 'name': 'название сети'},
height=500,
text = 'qty',
title='top15 сетей города')
fig.update_layout(
autosize=False,
height=600,
width=900,
uniformtext=dict(minsize=11, mode='hide'), # все надписи одним разером шрифта - 10 , если не входит - скрыть
margin = dict(t=30, l=0, r=0, b=0)
)
fig.show()
Посмотрим на распределение этих 15 крупнейших сетей по Москве:
Отфильтруем данные по сетям и названиям топ 15 сетей:
data1 = data.loc[(data['chain'] == 1) & (data['name'].isin(top_15['name'].to_list()))]
Сгруппируем данные по районам и названиям сетей и посчитаем кол-во каждой сети в каждом районе:
data2 = pd.pivot_table(data1, index=['name','district1'], values='chain', aggfunc='sum').reset_index()
data2.columns = ['name', 'district', 'qty']
data2['percent'] = data2['qty'] / data2.groupby(['name'])['qty']. transform('sum')
data2['percent'] = data2['percent'].round(3) * 100
data2.sort_values(by='percent', ascending=False)
| name | district | qty | percent | |
|---|---|---|---|---|
| 72 | Кулинарная лавка братьев Караваевых | ЦАО | 32 | 82.1 |
| 32 | Prime | ЦАО | 37 | 74.0 |
| 78 | Му-Му | ЦАО | 15 | 55.6 |
| 65 | КОФЕПОРТ | ЦАО | 15 | 35.7 |
| 95 | Хинкальная | ЦАО | 14 | 31.8 |
| ... | ... | ... | ... | ... |
| 92 | Хинкальная | САО | 1 | 2.3 |
| 31 | Prime | СЗАО | 1 | 2.0 |
| 30 | Prime | СВАО | 1 | 2.0 |
| 27 | Prime | ВАО | 1 | 2.0 |
| 115 | Шоколадница | ЮВАО | 2 | 1.7 |
126 rows × 4 columns
Посторим график на базе полученных выше данных:
fig = px.bar(data2, x='name', y='percent', color='district',
labels={'percent':'проценты', 'name': 'название сети','district':'район'},
height=500,
#text='qty',
text_auto=True,
title='Распределение top15 сетей по районам города')
fig.update_layout(
autosize=False,
height=600,
width=900,
uniformtext=dict(minsize=11, mode='hide'), # все надписи одним разером шрифта - 10 , если не входит - скрыть
margin = dict(t=30, l=0, r=0, b=0)
)
fig.show()
Из графика видно, что все сети представлены в Центральном административном округе Москвы. Причем у одних сетей этот округ является приоритетным:
Оставшиеся сети распределены в большей степени по другим округам.
dists = pd.pivot_table(data, index='district1', values= 'name', aggfunc='count').reset_index()
dists.columns = ['district', 'qty']
dists.sort_values(by='qty', ascending=False)
| district | qty | |
|---|---|---|
| 5 | ЦАО | 2241 |
| 2 | САО | 898 |
| 6 | ЮАО | 892 |
| 3 | СВАО | 890 |
| 1 | ЗАО | 849 |
| 0 | ВАО | 798 |
| 7 | ЮВАО | 714 |
| 8 | ЮЗАО | 709 |
| 4 | СЗАО | 409 |
fig = px.pie(dists, values='qty', names='district', title='Распределение заведений общественного питания по районам Москвы')
fig.update_traces(textposition='inside', textinfo='percent+label', hole=0.3)
# меняем размер графика
fig.update_layout(
autosize=False,
height=600,
width=900,
annotations=[dict(text='Москва', x=0.5, y=0.5, font_size=20, showarrow=False)],
margin = dict(t=30, l=0, r=0, b=0)
)
fig.show()
Больше всего заведений находится в ЦАО - как минимум все сетевые предприятия в нем представлены, за ЦАО следуют 3 рйона с примерно одинаковым кол-вом заведений: САО, ЮАО, СВАО, ЗАО. За ними еще 3 района тоже с похожим кол-вом заведений: ВАО, ЮВАО, ЮЗАО и замыкает районы СЗАО с намменьшим кол-вом заведений.
Посмотрим, как представлены категории заведений в районах города:
pv = pd.pivot_table(data, index=['district1', 'category'], values='name', aggfunc='count').reset_index()
pv.columns= ['district','category', 'qty']
pv = pv.sort_values(by='qty', ascending=False)
fig = px.bar(pv, x='district', y='qty', color='category',
labels={'district':'район', 'qty':'проценты', 'category': 'категория'},
height=500,
text_auto=True,
title='Распределение категорий заведений по районам города')
fig.update_layout(
autosize=False,
height=600,
width=900,
barnorm='percent',
uniformtext=dict(minsize=11, mode='hide'), # все надписи одним разером шрифта - 10 , если не входит - скрыть
margin = dict(t=30, l=0, r=0, b=0)
)
fig.show()
Во всех районах города три категории: кафе, ресторан , кофейня удерживают долю в районе 70%, как и по Москве в целом
Создадим таблицу с рейтингами каждой категории в каждом районе Москвы и добавим рейтинг в целом по Москве:
# создадим таблицу категории в строчках и районы в столбцах(можно было сделать через pivot, но в итоге выдает ошибку, разбираться нет времени)
inds = pv['category'].unique()
cols = pv['district'].unique()
pv_tab = pd.DataFrame(index=inds, columns=cols, data=[[0*len(inds)]*len(cols)])
for i in pv_tab.index:
for j in pv_tab.columns:
pv_tab.loc[i,j] = pv.loc[(pv['category']==i) & (pv['district']==j), 'qty'].to_numpy()[0]
#переименуем столбцы
pv_tab = pv_tab.reset_index()
pv_tab.columns = ['category','ВАО','ЗАО','САО','СВАО','СЗАО','ЦАО','ЮАО','ЮВАО','ЮЗАО']
# добавим информацию из таблицы с данными по Москве
pv_tab = pv_tab.merge(pt_cats, left_on=pv_tab['category'], right_on=pt_cats['category'] ).drop(['category_x','category_y'], axis=1)
pv_tab.columns = ['category', 'ВАО', 'ЗАО', 'САО', 'СВАО', 'СЗАО', 'ЦАО', 'ЮАО', 'ЮВАО', 'ЮЗАО', 'Москва']
pv_tab = pv_tab.sort_values(by='Москва', ascending=False)
# получим рейтинги каждой категории в каждом раоне Москвы
for i in pv_tab.columns[1:]:
str = i + ' rank'
pv_tab[str] = 9 - pv_tab[i].rank() # инвертируем значения рейтинга в более понятные
pv_tab.drop(columns=i, inplace=True)
pv_tab
| category | ВАО rank | ЗАО rank | САО rank | СВАО rank | СЗАО rank | ЦАО rank | ЮАО rank | ЮВАО rank | ЮЗАО rank | Москва rank | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | кафе | 2.0 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 |
| 0 | ресторан | 1.0 | 2.0 | 2.0 | 2.0 | 2.0 | 2.0 | 2.0 | 3.0 | 2.0 | 2.0 |
| 2 | кофейня | 3.0 | 3.0 | 3.0 | 3.0 | 3.0 | 3.0 | 3.0 | 2.0 | 3.0 | 3.0 |
| 3 | бар,паб | 4.0 | 6.0 | 6.0 | 6.0 | 6.0 | 6.0 | 6.0 | 5.0 | 6.0 | 4.0 |
| 4 | пиццерия | 5.0 | 5.0 | 4.0 | 5.0 | 5.0 | 4.0 | 4.0 | 4.0 | 4.0 | 5.0 |
| 5 | быстрое питание | 6.0 | 4.0 | 5.0 | 4.0 | 4.0 | 5.0 | 5.0 | 6.0 | 5.0 | 6.0 |
| 6 | столовая | 7.0 | 7.0 | 7.0 | 7.0 | 7.0 | 8.0 | 8.0 | 7.0 | 7.0 | 7.0 |
| 7 | булочная | 8.0 | 8.0 | 8.0 | 8.0 | 8.0 | 7.0 | 7.0 | 8.0 | 8.0 | 8.0 |
Что мы видим? "кафе" во всех районах, кроме центрального занимает первое место по кол-ву точек. В ЦАО на пермом месте - "ресторан". Это вполне ожидаемо - центр всегда ассоциировался с чем-то праздничным, торжественным, как и "ресторан". "Ресторан" - во всех районах кроме центрального, который мы только что обсудили, и северо-восточного на втором месте. В северо-восточном на втором месте находятся "кофейни". "Кофейня" - везде стоит на 3 месте по кол-ву торговых точек (кроме СВАО). А вот дальше идут 3 категории, которые в сравнении с местом по Москве в целом, по районам ведут себя по разному - меняясь друг с другом местами. Так "бар,паб" имея в целом по Москве 4 позицию, в районах почти везде спустился на 2 места ниже и занял 6 позицию, кроме САО. "пицерия" в 5 случаях из 9 осталась на своем месте, а в оставшихся 4 районах проиграла конкуренцию категории "быстрое питание". Осташиеся две категории "столовая" и "булочная" остались на своих местах замыкая список категорий, имсключение составили 2 района, где эти категории поменялись между собой местами.
pt_rat = pd.pivot_table(data=data, index='category', values='rating', aggfunc='mean').reset_index()
pt_rat['rating'] = pt_rat['rating'].round(2)
pt_rat = pt_rat.sort_values(by='rating', ascending=False)
Визуализируем полученные данные:
# Создаем на полотне графики
fig = px.bar(pt_rat, x='category', y='rating', text='rating',labels={'rating':'рейтинг','category': 'категория'})
# Заголовок
fig.update_layout(title_text='Средний рейтинг по категориям')
fig.show()
Мы видим, что средние срейтинги по категориям практически не отличаются друг от друга - все значения выше 4, более тоге они все находятс в пределах 4.3, за исключением категории "быстрое питание" у которй всего 4.05 и "кафе" - 4.12
Подготовим данные по средним рейтингам точек общепита по районам для визуализации:
pt = pd.pivot_table(data, index='district', values='rating', aggfunc='mean').reset_index().sort_values(by='rating', ascending=False)
pt['rating'] = pt['rating'].round(2)
pt
| district | rating | |
|---|---|---|
| 5 | Центральный административный округ | 4.38 |
| 2 | Северный административный округ | 4.24 |
| 4 | Северо-Западный административный округ | 4.21 |
| 8 | Южный административный округ | 4.18 |
| 1 | Западный административный округ | 4.18 |
| 0 | Восточный административный округ | 4.17 |
| 7 | Юго-Западный административный округ | 4.17 |
| 3 | Северо-Восточный административный округ | 4.15 |
| 6 | Юго-Восточный административный округ | 4.10 |
# читаем файл и сохраняем в переменной
try:
with open('/datasets/admin_level_geomap.geojson', 'r', encoding='utf-8') as f:
geo_json = json.load(f)
except:
with open('admin_level_geomap.geojson', 'r', encoding='utf-8') as f:
geo_json = json.load(f)
# moscow_lat - широта центра Москвы, moscow_lng - долгота центра Москвы
moscow_lat, moscow_lng = 55.751244, 37.618423
# создаём карту Москвы
my_map = Map(location=[moscow_lat, moscow_lng], zoom_start=10)
# создаём хороплет с помощью конструктора Choropleth и добавляем его на карту
Choropleth(
geo_data=geo_json,
data=pt,
columns=['district', 'rating'],
key_on='feature.name',
fill_color='YlGn',
fill_opacity=0.7,
legend_name='Средний рейтинг заведений по районам',
).add_to(my_map)
# выводим карту
my_map
Отобразим все точки общественного питания указанные в датасете на карте Москвы с помощью кластеров:
# создаём пустой кластер, добавляем его на карту
marker_cluster = MarkerCluster().add_to(my_map)
# пишем функцию, которая принимает строку датафрейма,
# создаёт маркер в текущей точке и добавляет его в кластер marker_cluster
def create_clusters(row):
# сохраняем URL-адрес изображения со значком торгового центра с icons8,
# это путь к файлу на сервере icons8
icon_url = 'https://img.icons8.com/external-wanicon-flat-wanicon/344/external-mall-shop-and-store-wanicon-flat-wanicon.png'
# создаём объект с собственной иконкой размером 30x30
icon = CustomIcon(icon_url, icon_size=(30, 30))
Marker(
[row['lat'], row['lng']],
popup=f"{row['name']} {row['rating']}",
icon=icon,
).add_to(marker_cluster)
# применяем функцию create_clusters() к каждой строке датафрейма
data.swifter.apply(create_clusters, axis=1)
# выводим карту
my_map
Pandas Apply: 0%| | 0/8400 [00:00<?, ?it/s]